package org.upfrost;

import java.beans.IntrospectionException;
import java.sql.SQLException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;


import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.upfrost.cache.Cache;
import org.upfrost.cache.QueryCache;
import org.upfrost.converter.BooleanNumberConverter;
import org.upfrost.converter.Converter;
import org.upfrost.converter.NumberConverter;
import org.upfrost.idiom.GenericSQL92;
import org.upfrost.idiom.SQLIdiom;
import org.upfrost.mapping.ClassAnalyzer;
import org.upfrost.mapping.ClassProxy;

/**
 * The Mapper is the high-level class that provides an access to all the mapping features. Typically, there's only
 * one Mapper class per application and per datasource, although there is nothing preventing several ones to
 * exist at the same time in the same application space.
 * <p/>
 * There's two ways to use a Mapper to access the database:
 * <ul>
 *  <li>Obtain a MapperSession with getSession()</li>
 *  <li>Use the execute() method</li>
 *  </ul>
 *  To add an object mapping to the mapper, use the addAnnotated() method. 
 *  
 * @author Rodrigo Reyes
 *
 */
public class Mapper 
{
	static private Log log = LogFactory.getLog(Mapper.class);
	
    private Cache m_cache = new Cache();
    private QueryCache m_queryCache = new QueryCache();

    private boolean m_cacheEnabled = true;
    
	private javax.sql.DataSource m_dataSource;
	private HashMap<Class, ClassProxy> m_proxy = new HashMap<Class, ClassProxy>();
	private HashMap<String, ClassProxy> m_tableNameProxy = new HashMap<String, ClassProxy>();
	private List<Converter> m_converter = new LinkedList<Converter>();
	private ThreadLocal<MapperSession> m_session = new ThreadLocal<MapperSession>();
	private HashSet<MapperSession> m_sessions = new HashSet<MapperSession>();
	private boolean m_autoCommitDefault = true;
	private SessionClosedListener m_sessionCloser = new SessionClosedListener();
	
	private long m_sessionsOpened = 0, m_sessionClosed =0;
	private long m_queriesCreated = 0, m_queriesClosed =0;

	private Statistics m_stats = new Statistics();

	private SQLIdiom m_idiom = new GenericSQL92();
	
	private class SessionClosedListener implements MapperSession.CloseListener
	{
		public void closedSession(MapperSession session) {
			if (m_sessions.remove(session))
			{
				m_stats.incClosedSession();
			}
			m_session.set(null);
		}
	}
	
	/**
	 * Create a new Mapper bound to the datasource provided
	 * @param dataSource a valid DataSource instance
	 */
	public Mapper(javax.sql.DataSource dataSource)
	{
		m_dataSource = dataSource;
		m_converter.add(new NumberConverter());
		m_converter.add(new BooleanNumberConverter());
	}

	/**
	 * Create a mapping for the classes
	 *  
	 * @param clazz
	 * @throws IntrospectionException
	 */
	public void addAnnotated(Class... clazz) throws IntrospectionException
	{
		for (Class c: clazz)
		{
			ClassProxy proxy = ClassAnalyzer.buildProxy(c, this);
			m_proxy.put(c, proxy);
			if (proxy.getTableName() != null && proxy.getTableName().length()>0)
			{
				m_tableNameProxy.put(proxy.getTableName().toLowerCase(), proxy);
			}
		}
	}

	/**
	 * Initialize a session associated to the current Thread. This session must be closed at some point.
	 * The behavior of an initialized session is to be kept light as long as it's not used (ie. lazy init), 
	 * so that it's not a performance issue when initialized in an OpenSessionInView pattern but not used.
	 * <p/> 
	 * If an open session is already associated to the current thread, an error is logged and the session is closed; this
	 * should be treated as an error, and means there's a potential session leak in your application.
	 *  
	 * @return the object itself, for chainability.
	 */
	public Mapper initSession(String sessionName)
	{
		MapperSession session = m_session.get();
		if (session != null)
		{
			log.error("Session not closed (" + session + ", " + sessionName +")");
			session.close();
		}
		
		try {
			session = new MapperSession(this, m_dataSource, m_sessionCloser, sessionName);
			session.setAutoCommit(isAutoCommitDefault());
			session.setCacheEnabled(isCacheEnabled());
			m_sessions.add(session);
			m_session.set(session);
			
			m_stats.incOpenedSession();
			
		} catch (SQLException e) {
			m_session.set(null);
			throw new RuntimeException("Can't create a new session: " + e.getMessage(), e);
		}
		
		return this;
	}
	
	
	/**
	 * Get the session associated to the current thread. The session must have been initialized by the initSession() method, 
	 * or an exception is raised.
	 * @return
	 */
	public MapperSession getSession()
	{
		MapperSession session = m_session.get();
		
		if (session == null)
			throw new RuntimeException(MapperSession.class.getSimpleName()+" was not initialized!");
		
		return session;
	}

	/**
	 * Test wether a session is already open for the current thread.   
	 * @return true is a session is open, false otherwise
	 */
	public boolean isLocalSessionOpen()
	{
		return m_session.get() != null;
	}

	/**
	 * Return the ClassProxy instance in charge of the mapping for the class.
	 * @param clazz a class
	 * @return a ClassProxy instance, or null if none is associated
	 */
	public <A> ClassProxy<A> getClassProxy(Class<A> clazz)
	{
		return m_proxy.get(clazz);
	}

	/**
	 * Return the ClassProxy instance associated to a SQL table. 
	 * @param tablename the name of an SQL table
	 * @return a ClassProxy instance, or null if none is associated 
	 */
	public ClassProxy<?> getClassProxy(String tablename)
	{
		return m_tableNameProxy.get(tablename.toLowerCase());
	}

	/**
	 * Return a list of Converter objects used by this Mapper instance to map sql types (as returned by the Jdbc drivers) and java classes
	 * @return a List of Converter objects.
	 */
	public List<Converter> getConverters() {
		return m_converter;
	}

	/**
	 * Add a Converter object to this mapper
	 * @param conv a Converter
	 */
	public void addConverter(Converter conv)
	{
		m_converter.add(conv);
	}

	/**
	 * Define how an object is considered as "set" for this mapper. This is used by the mapper to determine wether 
	 * a property is set (ie. not SQL null) or not. For instance, 0 is often used as a default value for primary keys
	 * mapped as int, and should be considered as "not set", which means the value of this key must be set by the
	 * database or the mapper.
	 * @param keyValue an property
	 * @return true if the object contains a valid value ("set") or false otherwise.
	 */
	public boolean isIdSet(Object keyValue)
	{
		if (keyValue == null)
			return false;
		
		if (keyValue instanceof Integer || keyValue instanceof Short || keyValue instanceof Long || keyValue instanceof Byte)
		{
			if (((Number)keyValue).intValue() == 0)
			{
				return false;
			}
		}
		
		return true;
	}

	/**
	 * 
	 * @return
	 */
	public boolean isAutoCommitDefault() {
		return m_autoCommitDefault;
	}

	/**
	 * 
	 * @param autoCommitDefault
	 */
	public void setAutoCommitDefault(boolean autoCommitDefault) {
		m_autoCommitDefault = autoCommitDefault;
	}
	
	/**
	 * 
	 * @return
	 */
	public Collection<ClassProxy> getManagedClasses()
	{
		return m_proxy.values();
	}
	
	/**
	 * 
	 * @return
	 */
	public Statistics getStatistics()
	{
		return m_stats;
	}
	
	public Set<MapperSession> getActiveSessions()
	{
		return m_sessions;
	}
	
	/**
	 * Execute an AtomicExecutionGroup. The mapper initializes (if needed) a session with autocommit disabled, and calls the AtomicExecutionGroup.execute() method
	 * providing this session as parameter. The method forward the returned object, and automatically validates all the changes to the database when 
	 * the AtomicExecutionGroup call returns. If an exception is raised during the execution, a rollback is done instead of a commit, in order to cancel
	 * any change. It's still possible, from the AtomicExecutionGroup to call the session.commit(), but it's better to let the Mapper.execute() handle it. 
	 * 
	 * @param <X> The type of the return object. If your execution group doesn't need to return anything, just use Void, and return null at the end of your execute() method.
	 * @param group the atomic group to execute
	 * @return the value returned by the AtomicExecutionGroup.execute() method
	 */
	public <X> X execute(AtomicExecutionGroup<X> group)
	{
		X result = null;
		
		MapperSession session = m_session.get();
		if (session == null)
		{
			session = this.initSession(group.toString()).getSession();
			try {
				session.setAutoCommit(false);
				result = group.execute(session);
				session.commit();
			} catch (Exception exc)
			{
				throw new RuntimeException("Error executing atomic group", exc);
			}
			finally {
				session.close();
			}
		}
		else
		{
			session.saveState();
			try {
				session.setAutoCommit(false);
				result = group.execute(session);
				session.commit();
			} catch (Exception exc)
			{
				// report & ignore
				throw new RuntimeException("Error executing atomic group", exc);
			} finally {
				session.restoreState();
			}
		}
		
		return result;
	}

	public Object convertTypeIfNeeded(Object instance, Class<?> targetClass)
	{
		if (instance != null && targetClass.isAssignableFrom(instance.getClass()) == false)
		{
			// here, we need some conversion
			Class<?> fromClass = instance.getClass();
			for (Converter conv: this.getConverters())
			{
				if (conv.handles(fromClass, targetClass))
				{
					return conv.convert(instance, targetClass);
				}
			}
		}
		
		return instance;
	}

	public SQLIdiom getIdiom() {
		return m_idiom;
	}

	public void setIdiom(SQLIdiom idiom) {
		m_idiom = idiom;
	}

	public Cache getCache() {
		return m_cache;
	}

	public void setCache(Cache cache) {
		m_cache = cache;
	}

	public QueryCache getQueryCache() {
		return m_queryCache;
	}

	public void setQueryCache(QueryCache queryCache) {
		m_queryCache = queryCache;
	}

	public boolean isCacheEnabled() {
		return m_cacheEnabled;
	}

	public void setCacheEnabled(boolean cacheEnabled) {
		m_cacheEnabled = cacheEnabled;
	}
	
}
